Skip to content

Accept and expose package attestation bundles - #9545

Open
mosuem wants to merge 9 commits into
mainfrom
accept-and-serve-attestations
Open

mosuem wants to merge 9 commits into
mainfrom
accept-and-serve-attestations

Conversation

@mosuem

@mosuem mosuem commented Aug 19, 2026 •

Copy link
Copy Markdown
Member

First PR in stack for package attestation support:

  • Adds AssetKind.attestation for storing attestation bundles as PackageVersionAsset entities in Datastore.
  • GET /api/packages/versions/new now returns an attestationUrl, pointing at POST /api/packages/versions/newUploadAttestation/<uploadId>. Repositories that do not support publishing with attestations omit the property, which is how pub detects support before uploading the archive (Support uploading package attestations during publish --from-archive pub#4876).
  • The new endpoint requires an authenticated client, validates that the upload id is a uuid, that the bundle is at most maxAttestationContentLength (128 KB), and that it parses as a JSON object, and then stores it as tmp/<guid>.attestation.json in the incoming bucket.
  • In packageBackend.publishUploadedBlob, reads that object if it is present, saves it as an attestation asset, and removes it together with the other temporary objects.
  • newUploadFinish remains a GET-only endpoint.
  • Attestations are not passed through capContent(): oversized bundles are rejected when they are uploaded rather than silently truncated, since a truncated bundle could never be verified.
  • Exposes GET /api/packages/<package>/versions/<version>/attestation endpoint for client retrieval.
  • Updates PubApiClientExt and adds unit tests in upload_test.dart.

Since the attestation is uploaded to the app server, publishing with an attestation does not require a second signed upload policy, and therefore no extra iam.signBlob call per publish.

Verifying the bundle against the uploaded archive (digest and provenance) follows in the next PR in the stack.

- Adds `AssetKind.attestation` for storing attestation bundles as `PackageVersionAsset` entities in Datastore.
- In `packageBackend.publishUploadedBlob`, checks for accompanying `tmp/<guid>.sigstore.json` in the incoming bucket, reads it, and saves it as an attestation asset.
- Exposes `GET /api/packages/<package>/versions/<version>/attestation` endpoint for client retrieval.
- Updates `PubApiClientExt` and adds unit tests in `upload_test.dart`.
@mosuem
mosuem force-pushed the accept-and-serve-attestations branch from 8d92052 to 8cae402 Compare August 20, 2026 08:45
@mosuem
mosuem marked this pull request as ready for review August 28, 2026 12:42
@mosuem
mosuem requested a review from sigurdm August 28, 2026 12:42
@mosuem
mosuem requested a review from jonasfj September 4, 2026 08:00
- Revert attestationUrl and attestationFields in UploadInfo, UploadSignerService, and FakeUploadSignerService.
- Support POST requests on /api/packages/versions/newUploadFinish and /api/packages/versions/newUploadFinish/<uploadId> to receive {'attestation': <bundle>} JSON body.
- Update PackageBackend.publishUploadedBlob to accept attestationContent directly rather than reading a separate object from Cloud Storage.
- Expose Client and sendRaw in api_builder to facilitate sending raw and POST finalize requests.
- Update PubApiClientExt.uploadPackageBytes to send attestation bundle in POST finalization request.
- Run codegen and update tests.
'/api/packages/versions/new' now returns an 'attestationUrl' pointing at
'/api/packages/versions/newUploadAttestation/<uploadId>', where the
client POSTs the attestation bundle before uploading the archive. The
bundle is stored in the incoming bucket next to the archive, and read,
stored and (later) verified when the upload is finished.

'newUploadFinish' is a GET endpoint again.

Also:
 * reject attestations over maxAttestationContentLength instead of
   silently truncating them with capContent(), as a truncated bundle
   could never be verified, and
 * validate that the upload id is a uuid before deriving an object name
   from it.

@sigurdm sigurdm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: since this PR advertises attestationUrl and accepts attestation uploads without verifying them yet, we should hold off on merging it to main until #9546 is ready to land right alongside it.

Comment on lines +2546 to +2556
if (attestationContent != null)
PackageVersionAsset.init(
package: key.package,
version: key.version,
kind: AssetKind.attestation,
versionCreated: versionCreated,
path: '${key.package}-${key.version}.sigstore.json',
// Note: not capped, a truncated attestation could never be verified.
// The length is checked when the attestation is uploaded.
textContent: attestationContent,
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Storing the attestation only as a PackageVersionAsset and serving it directly from the database in getPackageVersionAttestation has a few architectural issues:

  1. Canonical vs. derived storage: PackageVersionAsset is for derived data extracted from the package tarball (derivePackageVersionEntities for README, CHANGELOG, pubspec, license; see doc/entities.md). Because the attestation bundle is uploaded alongside the tarball rather than inside it, it is canonical data. If we only store it in PackageVersionAsset and delete tmp/<guid>.attestation.json from _incomingBucket, it is never stored in _canonicalBucket (packages/<package>-<version>...), and re-running derivePackageVersionEntities from the canonical archive would lose the attestation. It should be copied into _canonicalBucket alongside the tarball in _performTarballUpload.
  2. Serving via ExportedApi / GCS: GET /api/packages/<package>/versions/<version>/attestation will be called during dart pub get (in pub#4875). All other pub get endpoints (/api/packages/<pkg>, /api/packages/<pkg>/advisories, /api/archives/...) are exported to _exportedApiBucket via ExportedApi (app/lib/package/api_export/exported_api.dart) and served directly from GCS by GCLB (and exempted from Cloud Armor /api/ rate limits). Exporting attestations via ExportedApi also means ApiExporter.synchronizePackage will automatically remove exported attestations if a package or version is moderated (lookupPackageVersionAsset currently does not check isPackageVisible / pv.isNotVisible).
  3. Cloud Armor POST rate limit on upload: In production, Cloud Armor rate-limits non-GET requests to pub.dev to 5 requests per 3 minutes per IP (which wasn't hit by pub publish previously because the tarball POST goes to storage.googleapis.com, while new and newUploadFinish on pub.dev are GET requests with higher per-route limits). Sending POST /api/packages/versions/newUploadAttestation/<uploadId> to pub.dev will hit that limit when CI runners publish >5 packages from a monorepo in 3 minutes, so we'll need to adjust our Cloud Armor rules (or upload to GCS).

Comment on lines +85 to +96
@EndPoint.get('/api/packages/<package>/versions/<version>/attestation')
Future<Response> getPackageVersionAttestation(
Request request,
String package,
String version,
) async {
checkPackageVersionParams(package, version);
final asset = await packageBackend.lookupPackageVersionAsset(
package,
version,
AssetKind.attestation,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we keep a handler here (or as a fallback/redirect similar to fetchPackage), note that lookupPackageVersionAsset does not check whether the package or version has been moderated (isPackageVisible(package) / pv.isNotVisible), so it would still return 200 for moderated packages/versions rather than 404.

Comment on lines +181 to +191
Future<SuccessMessage> uploadPackageAttestation(
Request request,
String uploadId,
) async {
final bytes = await request.read().expand((i) => i).toList();
if (bytes.isEmpty) {
throw PackageRejectedException(
'Invalid attestation bundle format: the request body is empty.',
);
}
await packageBackend.uploadAttestation(uploadId, bytes);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

await request.read().expand((i) => i).toList() reads the entire request stream into memory (and unboxes/reboxes every byte) before uploadAttestation checks bytes.length > maxAttestationContentLength.

We have ByteFolderExt.foldBytes() on Stream<List<int>> in app/lib/shared/utils.dart (which uses BytesBuilder(copy: false)). Consider adding an optional {int? maxSize} check to foldBytes (similar to BucketExt.readAsBytes in app/lib/shared/storage.dart) so we abort reading the stream as soon as it exceeds maxAttestationContentLength.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants